Refactor game state#2235
Conversation
|
Play this branch at https://play.threadbare.game/branches/endlessm/wjt/refactor-game-state/. (This launches the game from the start, not directly at the change(s) in this pull request.) |
6484f38 to
ed15974
Compare
| interact_area.interaction_ended.connect(self._on_interaction_ended) | ||
|
|
||
| if GameState.incorporating_threads: | ||
| if GameState.state.quest_state.incorporating_threads: |
There was a problem hiding this comment.
It's clear that this is less clear than the previous one because you have to use .state.[kind_state] everywhere. I could make it simpler by proxying the quest_state and player_state properties out one layer so you could write: GameState.quest_state.incorporating_threads
or even:
GameState.quest.incorporating_threads
?
There was a problem hiding this comment.
Yes I like it! GameState.quest reads as "the state of the quest", 👍 .
There was a problem hiding this comment.
The weird thing about this is that QuestState has a quest property so one ends up writing GameState.quest.quest to access it. Tolerable I think.
| if ResourceLoader.exists(SAVE_PATH): | ||
| state = ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) | ||
|
|
||
| if state: | ||
| state = state.duplicate_deep(Resource.DEEP_DUPLICATE_INTERNAL) |
There was a problem hiding this comment.
TODO: per #2250 this should be deferred, we should only load the saved state once we know for sure we want to do that rather than use transient state.
There was a problem hiding this comment.
This is surprisingly hard to do and this PR has already got quite big so I'll not work on this further for now.
ed15974 to
4004fdf
Compare
4004fdf to
782734e
Compare
eb7e9fd to
7d2c84a
Compare
manuq
left a comment
There was a problem hiding this comment.
This is a huge refactor and you make it look so easy (I know it wasn't). Doing the storage with just resources and ResourceLoader/ResourceSaver rather than ConfigFile for INI files is great.
I like how the player state can be one or another, the global or the quest-specific one.
| if next_scene: | ||
| GameState.set_challenge_start_scene(next_scene) | ||
| assert(GameState.quest) |
There was a problem hiding this comment.
This could be written as a conditional, like in other places:
if next_scene and GameState.quest:
...
There was a problem hiding this comment.
I asserted here because I think this situation should not occur - in the current model these items should only be found during quests - but maybe it's a bit extreme. I replaced this with:
if next_scene:
- assert(GameState.quest)
- GameState.quest.challenge_start_scene = next_scene
+ if GameState.quest:
+ GameState.quest.challenge_start_scene = next_scene
+ else:
+ push_warning("Collectible collected while not on a quest")
switch()| set(new_value): | ||
| push_error("Do not set GameState.global") |
There was a problem hiding this comment.
Interesting way of using the setter to forbid setting the property!
There was a problem hiding this comment.
It feels like a bit of a kludge but since there is no such thing at the GDScript level as a readonly property, this is the best I could think of!
| ## inventory. Note that this does not actually switch to the first scene of [param | ||
| ## new_quest]. | ||
| func start_quest(new_quest: Quest) -> void: | ||
| # TODO: this suggests that the inventory should be part of QuestState |
There was a problem hiding this comment.
Yes, although this may change in the near future...
There was a problem hiding this comment.
Indeed, that's why I didn't think too hard about it :)
| if err != OK: | ||
| push_error("Failed to save settings to %s: %s" % [GAME_STATE_PATH, err]) | ||
|
|
||
| print("Saving game") |
There was a problem hiding this comment.
I understand this is not a leftover, but an actual print, now that the game is saved less frequently. So 👍
There was a problem hiding this comment.
Actually I left it in by mistake. I'm on the fence about it!
| # SPDX-FileCopyrightText: The Threadbare Authors | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| class_name PerSceneState |
7d2c84a to
0725700
Compare
GameState mixes the actual load/save logic with the state itself, and
also is based on a mixture of directly accessing the contents of a
ConfigFile object and fields of the GameState node.
State API
---------
Factor all the state itself out to a (shallow) tree of Resource subclasses:
- SavedGame
- GlobalState
- PlayerState
- QuestState (nullable)
- Quest-specific PlayerState instance
- PerSceneState
Expose the GlobalState, QuestState, and PerSceneState resources on
GameState with short names to save on typing when referring to them. Add
a special GameState.player property which is a proxy for the
quest-specific PlayerState when on a quest and the global PlayerState
otherwise. Update all code in the game to refer to these.
Remove `@tool` from GameState, and instead short-circuit in other
scripts which need to be `@tool` but refer to GameState.
Storage
-------
Replace the ConfigFile-based load and save logic with ResourceLoader and
ResourceSaver. This avoids writing bespoke serialisation code & makes it
relatively easy to add new saved fields – drop an `@export` into the
appropriate state class – at the cost of coupling the savegame format to
these Resource subclasses existing at these exact paths. This is the
approach recommended, among others, by GDQuest.
https://www.gdquest.com/library/save_game_godot4/
The state conceptually needs to refer to Quest resources. Storing these
as resources would mean that the `saved_game.tres` would have an
`ext_resource` header for each quest.tres being referred to, and moving
a quest elsewhere in the source tree (or deleting it) will cause the
whole `saved_game.tres` to fail to load. Mark the Quest and Array[Quest]
as not to be stored, and define stored proxy properties that convert to
and from resource_path strings. This matches how these were previously
stored.
Similarly, the inventory is in terms of InventoryItem resources. Unlike
Quests we don't store these in the game repo but define them inline
wherever they are used. I think this is a bad design - it would be
better to use just the ItemType enum throughout given that we hardcode
exactly 3 items and 2 textures for each - but to avoid this PR getting
even further out of hand, translate these to and from the ItemType enum
member name when storing, as before.
The result of jumping through these hoops is that the `saved_game.tres`
only refers to the scripts that define aspects of the game state. I
think that's acceptable coupling. I am disappointed to have to write
quite a bit of what amounts to custom serialisation code - I was hoping
to avoid that by using ResourceLoader/ResourceSaver - but it's still a
net improvement I think.
I did not make any attempt to migrate the old save game format to the
new one. This game is small, the state has little impact, and this will
also mean more people see the tutorial. :)
Saving
------
Only save the state in a few specific places, rather than whenever parts
of the state change (if we remembered to add a _save() call):
- When a checkpoint is activated (so that if you quit the game with
Alt+F4 after activating a checkpoint, it is kept);
- When reloading the current scene, typically due to being defeated, to
record the loss of life;
- When switching to a new scene;
- When you use the debug menu to tweak the completed quests.
Resolves #2016
0725700 to
ab7aeb9
Compare
GameState mixes the actual load/save logic with the state itself, and
also is based on a mixture of directly accessing the contents of a
ConfigFile object and fields of the GameState node.
State API
Factor all the state itself out to a (shallow) tree of Resource subclasses:
Expose the GlobalState, QuestState, and PerSceneState resources on
GameState with short names to save on typing when referring to them. Add
a special GameState.player property which is a proxy for the
quest-specific PlayerState when on a quest and the global PlayerState
otherwise. Update all code in the game to refer to these.
Remove
@toolfrom GameState, and instead short-circuit in otherscripts which need to be
@toolbut refer to GameState.Storage
Replace the ConfigFile-based load and save logic with ResourceLoader and
ResourceSaver. This avoids writing bespoke serialisation code & makes it
relatively easy to add new saved fields – drop an
@exportinto theappropriate state class – at the cost of coupling the savegame format to
these Resource subclasses existing at these exact paths. This is the
approach recommended, among others, by GDQuest.
https://www.gdquest.com/library/save_game_godot4/
The state conceptually needs to refer to Quest resources. Storing these
as resources would mean that the
saved_game.treswould have anext_resourceheader for each quest.tres being referred to, and movinga quest elsewhere in the source tree (or deleting it) will cause the
whole
saved_game.tresto fail to load. Mark the Quest and Array[Quest]as not to be stored, and define stored proxy properties that convert to
and from resource_path strings. This matches how these were previously
stored.
Similarly, the inventory is in terms of InventoryItem resources. Unlike
Quests we don't store these in the game repo but define them inline
wherever they are used. I think this is a bad design - it would be
better to use just the ItemType enum throughout given that we hardcode
exactly 3 items and 2 textures for each - but to avoid this PR getting
even further out of hand, translate these to and from the ItemType enum
member name when storing, as before.
The result of jumping through these hoops is that the
saved_game.tresonly refers to the scripts that define aspects of the game state. I
think that's acceptable coupling. I am disappointed to have to write
quite a bit of what amounts to custom serialisation code - I was hoping
to avoid that by using ResourceLoader/ResourceSaver - but it's still a
net improvement I think.
I did not make any attempt to migrate the old save game format to the
new one. This game is small, the state has little impact, and this will
also mean more people see the tutorial. :)
Saving
Only save the state in a few specific places, rather than whenever parts
of the state change (if we remembered to add a _save() call):
Alt+F4 after activating a checkpoint, it is kept);
record the loss of life;
Resolves #2016